Overview
HexDroid keeps the app itself tiny with a script interpreter, a UI renderer, and the crypto primitives, and letting behaviour arrive as user-loadable .hex scripts. A script can react to incoming messages, add slash-style commands, call out to the web, schedule work, and even paint a full interactive screen.
The language is small and mIRC-flavoured, but it is its own thing. If you have written an mIRC remote or an aliases file before, most of this will feel familiar; if not, the examples below are enough to start from scratch.
http.get/http.post, which the app gates. There is one generic view renderer, so a script describes a screen, it never ships native code.Scripts are in Menu > Scripts. The bundled scripts shipped with HexDroid are disabled by default, you opt it in. You can also paste, import, edit, and revert scripts from there; see Installing & editing.
Anatomy of a script
A .hex file is a flat list of two kinds of block:
on <event> { … }, an event handler that runs when something happens.alias <name> { … }, a reusable command you can call from anywhere (and that the user can run as/name).
Inside a block, statements are separated by a newline or a pipe |. Comments start with ; and run to the end of the line.
; greeter.hex, say hello when someone joins your name to the channel
on TEXT {
if ($contains($text, $me) == true) {
msg $chan hi $nick, you mentioned me ; newline or | separates statements
}
}
alias wave { msg $chan /me waves at $1- } ; usable as: /wave everyone
That is the whole shape of it: handlers react to events, aliases package up commands, and statements do the work. Everything else on this page is the vocabulary, the events you can hook, the variables you can read, the commands and functions you can call, and the view DSL for drawing screens.
Events
An on <event> block registers a handler. Event keys are case-insensitive.
| Event | Fires when… |
|---|---|
on LOAD | the script is loaded or reloaded (e.g. on app start, or after you enable/edit it). Use it to set defaults. |
on TEXT | a message arrives in any buffer. The handler can read, rewrite, or suppress the line before it is shown, see the pipeline note below. |
on SIGNAL:NAME | a custom signal NAME is raised, by the signal command, a timer, a view button, or another script. |
Capabilities raise signals too: the encrypted transport delivers messages as on SIGNAL:age_msg and dealt secrets as on SIGNAL:age_deal (see Encrypted transport). The same on SIGNAL:… form handles them.
The TEXT pipeline
Inside on TEXT you can change what the user actually sees:
rewrite <new text>, replace the line's text (e.g. to strip or annotate it).halt, stop processing and drop the line entirely.
on TEXT {
if ($contains($text, "spoiler") == true) { halt } ; hide spoilers
rewrite $replace($text, ":wave:", "👋") ; expand a shortcode
}
Variables & arguments
Global variables, %name
Variables prefixed with % persist for the life of the script. They hold strings, numbers, lists, or maps.
| Command | Effect |
|---|---|
set %x <value> | assign (omit the value to clear it) |
unset %x | remove the variable |
inc %x/dec %x | increment/decrement a numeric variable |
push %list <value> | append to a list |
setat %coll <key> <value> | set a key/index inside a map or list (nestable) |
Built-in fields, $field
Inside a handler, these read-only fields describe the event:
| Field | Value |
|---|---|
$me | your current nick |
$nick | the sender of the message/event |
$chan/$buffer/$target | the buffer the event belongs to (channel or query) |
$network | the network id |
$text | the message text (in on TEXT) |
$isme | true if the message is your own, else false |
Positional arguments, $1, $2-
When you call an alias, or raise a signal with extra words, or attach args to a view button, those words bind to numbered arguments:
| Token | Meaning |
|---|---|
$1, $2, … | the first, second, … argument |
$2- | every argument from the second onward, space-joined |
$1- | all arguments, space-joined |
Expansion is recursive: %vars, $fields, and $func(…) calls can all be nested inside each other.
alias greet {
set %who $1 ; first argument
msg $chan Hello $%who, and also $2- ; mix args, vars, and rest
}
Commands
A statement is a command followed by arguments. User aliases are commands too; any unknown verb is forwarded to the normal slash-command pipeline.
| Command | Description | Example |
|---|---|---|
echo | print a local line to a buffer (not sent to the server) | echo $chan loaded ok |
msg | send a message to a target | msg #room hello all |
raw | send a raw IRC line to the server | raw WHO #room |
rewrite | (in on TEXT) replace the shown text | rewrite $upper($text) |
set/unset/inc/dec/push/setat | variable operations (see above) | setat %score $nick 0 |
signal | raise SIGNAL:NAME with optional args | signal refresh $chan |
timer | raise a signal after a delay (ms), the key to non-blocking work | timer 500 refresh |
http.get/http.post | make a web request, deliver the result to a signal (see HTTP) | http.get $url done $chan |
view { … } | build and mount an interactive screen (see Views) | view { text "Hi" bold } |
toast | show a brief on-screen notice | toast Saved |
halt/return | stop the current handler (return may yield a value) | halt |
<namespace>.<method> | call a host capability, e.g. the age.* transport | age.send $chan move 5 |
join #room or nick NewNick.Value functions
Functions are written $name(arg, arg, …) and return a value you can use anywhere a value is expected.
Strings & math
| Function | Returns |
|---|---|
$len(s) | length of a string |
$lower(s)/$upper(s) | case conversion |
$left(s,n)/$right(s,n) | first/last n characters |
$substr(s,start,len) | substring |
$replace(s,find,with) | replace all occurrences |
$trim(s) | strip surrounding whitespace |
$contains(s,sub)/$indexof(s,sub) | membership test/position |
$repeat(s,n) | repeat a string |
$calc(expr) | evaluate an arithmetic expression |
$mod(a,b)/$int(n)/$abs(n) | modulo/floor/absolute value |
$min(…)/$max(…) | smallest/largest of the arguments |
$urlencode(s) | percent-encode for a URL or form body |
$setting(key) | read a host setting (e.g. $setting(applang)) |
Lists & maps
| Function | Returns |
|---|---|
$list(a,b,…) | build a list |
$map(k,v,k,v,…) | build a map from key/value pairs |
$get(coll,key) | element by index, or value by key |
$has(coll,key) | true if the key/value is present |
$keys(map)/$values(map) | the keys/values of a map |
$len(coll) | element count |
$split(s,sep)/$join(list,sep) | string ↔ list |
$sort(list)/$reverse(list) | sorted/reversed copy |
$slice(list,from,to) | sub-list |
$concat(a,b,…) | join lists end to end |
$find(list,x)/$count(list,x) | index of/number of occurrences |
$sum(list) | numeric total |
$range(lo,hi) | a list of integers from lo to hi |
Control flow
Conditions live in parentheses and combine with && and ||. Comparisons support ==, !=, <, >, <=, >=, plus isin (substring) and iswm (wildcard match).
if ($nick == ChanServ) {
; ignore services
} elseif ($len(%warned) > 3 && $isme == false) {
msg $chan settle down
} else {
inc %warned
}
foreach %p $keys(%score) {
echo $chan $%p has $get(%score, %p) points
}
while ($len(%queue) > 0) {
; ... process and shrink %queue ...
}
foreach <item> <collection> walks a list or a map's keys. halt stops the whole handler; return exits the current alias (optionally with a value).
HTTP & timers
Web requests are asynchronous: you name a signal to receive the result, plus any context words you want passed along.
; http.get <url> <signal> [context...] ; http.post <url> <body> <signal> [context...] http.post https://libretranslate.example/translate q=$urlencode($text)&source=auto&target=en done $chan
In the receiving handler, the response is available through these fields, and $json(body, key) pulls a top-level value out of a JSON reply:
| Field/function | Value |
|---|---|
$httpok | true if the request succeeded |
$httpstatus | HTTP status code |
$httpbody | the raw response body |
$json(body, key) | a top-level field from a JSON body |
on SIGNAL:done {
if ($httpok == true) {
echo $1 ↳ $json($httpbody, translatedText) ; $1 = the buffer we passed as context
}
}
timer for anything repetitive or heavy. A handler runs on the UI thread, so don't loop a screen-repaint synchronously. Schedule the next step with timer <ms> <signal> instead, each tick yields, the UI stays responsive, and you avoid the "app not responding" trap.Interactive views
A view { … } block describes a screen using a small layout DSL, then mounts it. Rebuild and re-mount it whenever your state changes to "redraw". Buttons report taps back as signals, so a view plus a few on SIGNAL handlers is a complete little app.
Elements
| Element | What it is |
|---|---|
column { … }/row { … } | vertical/horizontal stacks of children |
stack { … }/ring { … } | overlay children/arrange them in a circle |
surface { … } | a panel (background, padding, elevation, gradient) |
text "…" | a label |
button "label" <actionId> [args] | a tappable button that raises on SIGNAL:<actionId> |
card "Ah" [red] [back] | a playing card (rank+suit, optionally face-down) |
image "<url>" | a remote image |
spacer | flexible empty space |
Modifiers
Any element takes trailing modifiers: bold, fill, wrap (on a row, flow children onto extra lines instead of overflowing), circle, color <hex>, bg <hex>, bgimage <url>, gradient linear:<a>:<b>, textsize <sp>, width/height/size/radius <dp>, pad/gap <dp>, weight <n>, align <where>, border <hex> [w], offsetx/offsety <dp>, and elevation <dp>. On an image, the scale mode is crop (the default), fit, or stretch.
alias counter_render {
view {
surface bg #13243f radius 20 pad 16 {
column gap 12 align center {
text Counter bold color #ffffff textsize 18
text %n color #8fd0ff textsize 40
row gap 10 {
button "-1" dec weight 1
button "+1" inc weight 1
}
button "Reset" reset fill
}
}
}
}
on LOAD { set %n 0 }
on SIGNAL:inc { inc %n | counter_render }
on SIGNAL:dec { dec %n | counter_render }
on SIGNAL:reset { set %n 0 | counter_render }
SIGNAL:view_closed. Handle it to stop any timers or loops your view was driving, so a pending tick can't re-open it.Encrypted transport (advanced)
Scripts that need to talk to other players or peers can use the age.* capability, the same Ed25519 + X25519 machinery behind +AGE. It provides identity (age.me), randomness and hashing (age.rand, age.sha), sending over a keyed channel (age.send), local loopback for solo/practice play (age.local), and sealing a secret to one recipient (age.seal). Inbound traffic arrives as on SIGNAL:age_msg (and dealt secrets as on SIGNAL:age_deal).
The complete on-the-wire format, encoding, key derivation, sealed invites, the signed channel layer, and the 1:1 handshake and double ratchet, is published in the age-wire-format specification, so other clients can interoperate. For message-level chat encryption you do not need scripting at all, see the Encryption guide.
Worked examples
1 · A dice roller
Adds /roll (and /roll 20 for a d20).
alias roll {
set %sides $1
if ($len(%sides) == 0) { set %sides 6 } ; default d6
set %r $calc($int($calc($rand * %sides)) + 1) ; if your build lacks $rand, see note below
msg $chan rolls a %sides-sided die: %r
}
No built-in RNG? Derive one from age.rand or seed from $calc on a changing value, e.g. hash the time with age.sha and take $mod of the result.
2 · Keyword highlighter
Locally flags lines that mention a watch-word, without touching the server.
on LOAD { set %watch deploy }
on TEXT {
if ($isme == false && $contains($lower($text), %watch) == true) {
echo $chan ⚠ watch-word from $nick
}
}
3 · Auto-translate incoming lines
The pattern the bundled translate.hex uses: post each foreign line to a translation endpoint and echo the result underneath. Set your own endpoint and key at the top.
on LOAD {
set %lang $setting(applang)
if ($len(%lang) == 0) { set %lang en }
set %ep https://libretranslate.example/translate ; your endpoint
set %key ; API key (blank = none)
}
on TEXT {
if ($isme == false) {
if ($len(%key) > 0) { http.post %ep q=$urlencode($text)&source=auto&target=%lang&api_key=%key tr $chan $text }
else { http.post %ep q=$urlencode($text)&source=auto&target=%lang tr $chan $text }
}
}
on SIGNAL:tr {
if ($httpok == true) {
set %out $json($httpbody, translatedText)
if (%out != $2-) { echo $1 ↳ %out } ; $1 = buffer, $2- = original text
}
}
4 · A scoreboard with a view
Tracks points per nick (/point nick) and shows a live board.
on LOAD { set %score $map() }
alias point {
if ($has(%score, $1) == false) { setat %score $1 0 }
setat %score $1 $calc($get(%score, $1) + 1)
board_render
}
alias board_render {
view {
surface bg #161b22 radius 16 pad 16 {
column gap 8 {
text Scoreboard bold textsize 18 color #ffffff
foreach %p $sort($keys(%score)) {
row gap 8 { text %p weight 1 | text $get(%score, %p) bold color #58a6ff }
}
button "Clear" clear fill
}
}
}
}
on SIGNAL:clear { set %score $map() | board_render }
Installing & editing
- Enable/disable, bundled scripts ship disabled; flip the toggle to opt in. A disabled script never runs and never registers its commands or launchers.
- Import/Paste, add your own
.hexfrom a file or the clipboard. - Edit, opens a full-screen editor with line numbers. Save & reload writes it back and reloads immediately, so changes (like an endpoint or API key) take effect at once.
- Revert, bundled scripts can be restored to their shipped default if an edit goes wrong.
- Remove, delete a script you added.
set %… line near the top, and Save & reload. There is no separate settings field, the script is the configuration.From here, browse the command reference for everything a script can drive, or the encryption guide for message-level E2EE that needs no scripting at all.